<?php

require_once 'Zend/Db/Adapter/Abstract.php';
require_once 'Zend/Filter/Inflector.php';
require_once 'Zend/Registry.php';

/**
 * Implementation of the ActiveRecord pattern. Uses the Zend_Db database drivers
 * which come with the Zend Framework.
 * 
 * This implementation of ActiveRecord determines object relationships on the
 * fly and caches the database metadata for optimization. Using the location
 * of foreign keys and the plurality of the lookup (e.g. $person->addresses or
 * $person->address) ActiveRecord can know how the two objects relate.
 * 
 * In addition, this implementation of ActiveRecord keeps all non-model properties
 * out of the class (such as $table, $class, etc) in order to keep the object
 * clean for passing to other applications such as webservices or Flash remoting.
 * 
 * The default database schema may be modified for different
 * database layouts. Variables which may be included in the strings are:
 * %table% %plural_table% %singular_table%
 * 
 * Example of use:
 * 
 * require("ActiveRecord.php");
 * ActiveRecord::$db = Zend_Db::factory($config->database->adapter, $config->database);
 * 
 * class Tag extends ActiveRecord {}
 * 
 * class Note extends ActiveRecord {}
 * 
 * $n = Note::fetchFirst(array("user_id = ?", $_GET['user_id']));
 * foreach ($note->tags as $tag) {
 *     echo $tag->name;
 * }
 * 
 */

abstract class Zend_Db_ActiveRecord_Abstract
{
	/**
	 * Default Zend_Db_Adapter_Abstract object.
	 *
	 * @var Zend_Db_Adapter_Abstract
	 */
	protected static $_defaultDb;
	
	/**
	 * Default cache for information provided by the adapter's describeTable() method.
	 *
	 * @var Zend_Cache_Core
	 */
	protected static $_defaultMetadataCache = null;
	
	/**
	 * Zend_Filter_Inflectors for the classes registered with ActiveRecord.
	 * Default inflectors can be set to handle the most common schema.
	 */
	protected static $_defaultInflectors = array();
	
	/**
	 * Zend_Filter_Inflectors for the classes registered with ActiveRecord.
	 * Default inflectors can be set to handle the most common schema.
	 */
	protected $_inflectors = array();
	
	/**
	 * Zend_Db_Adapter_Abstract object.
	 *
	 * @var Zend_Db_Adapter_Abstract
	 */
	protected $_db;
	
	/**
	 * Class name
	 */
	protected $_className;
	
	public static $timeOffset = 0;
	protected static $_emptyObjects = array();
	public static $loadOnGet = true;

	
	protected function _createInflectors()
	{
		$basic = array(
			'table' => new Zend_Filter_Inflector(':className', array(
				':className' => array('Word_SingularToPlural', 'Word_CamelCaseToUnderscore', 'StringToLower')
			)),
			'id' => new Zend_Filter_Inflector('id', array(
				'className' => array()
			)),
			'primaryKey' => new Zend_Filter_Inflector('id', array(
				'className' => array()
			)),
			'foreignKey' => new Zend_Filter_Inflector(':className_id', array(
				':className' => array('Word_CamelCaseToUnderscore', 'StringToLower')
			)),
			'joinTable' => new Zend_Filter_Inflector(':className1_:className2', array(
				':className1' => array('Word_SingularToPlural', 'Word_CamelCaseToUnderscore', 'StringToLower'),
				':className2' => array('Word_SingularToPlural', 'Word_CamelCaseToUnderscore', 'StringToLower')
			)),
			'field' => new Zend_Filter_Inflector(':propertyName', array(
				':propertyName' => array('Word_CamelCaseToUnderscore', 'StringToLower')
			)),
			'property' => new Zend_Filter_Inflector(':fieldName', array(
				':fieldName' => array('Word_UnderscoreToLowerCamelCase')
			)),
			'dateModified' => new Zend_Filter_Inflector('modified', array(
				'className' => array()
			)),
			'dateCreated' => new Zend_Filter_Inflector('created', array(
				'className' => array()
			))
		);
		
		$this->_inflectors = array_merge($basic, self::$_defaultInflectors, $this->_inflectors);
	}

	/**
	 * Set the database configuration for default inflectors
	 * 
	 * @param Zend_Config $config A config object
	 */
	public static function setDefaultConfig($config)
	{
		$inflectors = array();
		foreach ($config as $key => $value) {
			$inflectors[$key] = new Zend_Filter_Inflector($value);
		}
		self::setDefaultInflectors($inflectors);
	}

	/**
	 * Set the database configuration for this instance's inflectors
	 * 
	 * @param Zend_Config $config A config object
	 */
	public function setConfig($config)
	{
		$inflectors = array();
		foreach ($config as $key => $value) {
			$inflectors[$key] = new Zend_Filter_Inflector($value);
		}
		$this->setInflectors($inflectors);
	}

	
	public static function setDefaultInflectors(array $inflectors = array())
	{
		self::$_defaultInflectors = $inflectors;
	}

	public static function getDefaultInflectors()
	{
		return self::$_defaultInflectors;
	}

	
	public function setInflectors(array $inflectors = array())
	{
		$this->_inflectors = $inflectors;
		$this->_createInflectors();
	}

	public function getInflectors()
	{
		return $this->_inflectors;
	}

	
	/**
	 * Sets the default Zend_Db_Adapter_Abstract for all active record objects.
	 *
	 * @param  mixed $db Either an Adapter object, or a string naming a Registry key
	 * @return void
	 */
	public static function setDefaultAdapter($db = null)
	{
		self::$_defaultDb = self::_setupAdapter($db);
	}

	/**
	 * Gets the default Zend_Db_Adapter_Abstract for all active record objects.
	 *
	 * @return Zend_Db_Adapter_Abstract or null
	 */
	public static function getDefaultAdapter()
	{
		return self::$_defaultDb;
	}

	/**
	 * @param  mixed $db Either an Adapter object, or a string naming a Registry key
	 * @return Zend_Db_ActiveRecord_Abstract Provides a fluent interface
	 */
	protected function _setAdapter($db)
	{
		$this->_db = self::_setupAdapter($db);
		return $this;
	}

	/**
	 * Gets the Zend_Db_Adapter_Abstract for this particular active record object.
	 *
	 * @return Zend_Db_Adapter_Abstract
	 */
	public function getAdapter()
	{
		return $this->_db;
	}
	
	/**
	 * Gets the Zend_Db_ActiveRecord_Gateway singleton for this active record.
	 * 
	 * @return Zend_Db_ActiveRecord_Gateway
	 */
	public function getGateway()
	{
		return Zend_Db_ActiveRecord_Gateway::getInstance($this->_className);
	}

	/**
	 * @param  mixed $db Either an Adapter object, or a string naming a Registry key
	 * @return Zend_Db_Adapter_Abstract
	 * @throws Zend_Db_Exception
	 */
	protected static function _setupAdapter($db)
	{
		if ($db === null) {
			return null;
		}
		if (is_string($db)) {
			require_once 'Zend/Registry.php';
			$db = Zend_Registry::get($db);
		}
		if (!$db instanceof Zend_Db_Adapter_Abstract) {
			require_once 'Zend/Db/Table/Exception.php';
			throw new Zend_Db_Exception('Argument must be of type Zend_Db_Adapter_Abstract, or a Registry key where a Zend_Db_Adapter_Abstract object is stored');
		}
		return $db;
	}

	/**
	 * Initialize database adapter.
	 *
	 * @return void
	 */
	protected function _setup()
	{
		$this->_className = get_class($this);
		$this->_createInflectors();
		
		// setup database adapter
		if (!$this->_db) {
			$this->_db = self::getDefaultAdapter();
			if (!$this->_db instanceof Zend_Db_Adapter_Abstract) {
				require_once 'Zend/Db/Table/Exception.php';
				throw new Zend_Db_Exception('No adapter found for ' . get_class($this));
			}
		}
	}

	protected function _formatDate($time)
	{
		return date("Y-m-d H:i:s", $time);
	}


	/*****************************************************/
	/* METHODS TO BE OVERRIDDEN (optional)               */
	/*****************************************************/
	
	protected function init()
	{
	
	}

	protected function preload()
	{
	
	}

	protected function postload()
	{
	
	}

	protected function presave()
	{
	
	}

	protected function postsave()
	{
	
	}
	

	/*****************************************************/
	/* PROTECTED VARIABLES, DON'T MODIFY                 */
	/*****************************************************/
	
	protected static $typeconv = array("date|time" => "date", "int|double|decimal|long|short" => "numeric");
	protected static $prepStmts = array();

	/**
	 * Constructor of object
	 *
	 * @param mixed $id [Optional] If id is present, will load object by $id, if
	 * $id is an array, will populate object's properties from the array.
	 */
	public function __construct($id = null)
	{
		$this->_setup();
		$this->init();
		
		if (is_array($id)) {
			// used internally for populating from a row rather than populating
			// the defaults. Done for optimization with the gateway.
			$this->setDBProperties($id);
		} elseif ($id != null) {
			$this->load($id);
		} else {
			$this->setDBProperties();
		}
	}
	
	/**
     * Creates and returns a new Zend_Db_Select object for this ActiveRecord.
     *
     * @return Zend_Db_Select
     */
	public function select()
	{
		return $this->_db->select();
	}

	/**
	 * Loads the object from the database by the id passed
	 *
	 * @param mixed $id
	 * @return boolean Whether the object was successfully loaded
	 */
	public function load($id)
	{
		$tableName = $this->inflect('table');
		$pk = $this->inflect('primaryKey');
		
		$select = $this->select();
		if (!$select->getPart('from')) {
			$select->from($tableName)->where("$pk = ?");
		}
		$stmt = $this->_db->prepare($select);
		$stmt->setFetchMode(Zend_Db::FETCH_ASSOC);
		
		if (!$stmt->execute(array($id))) {
			return false;
		}
		
		$row = $stmt->fetch();
		$this->setDBProperties($row);
		
		return true;
	}

	/**
	 * Loads the object from the database by the conditions passed
	 *
	 * @param Zend_Db_Select $select Where conditions to be passed
	 * @return boolean Whether the object was succesfully loaded
	 */
	public function loadBy(Zend_Db_Select $select)
	{
		if (!$select->getPart('from')) {
			$tableName = $this->inflect('table');
			$select->from($tableName);
		}
		
		$stmt = $this->_db->prepare($select);
		$stmt->setFetchMode(Zend_Db::FETCH_ASSOC);
		
		if (!$stmt->execute()) {
			return false;
		}
		
		$row = $stmt->fetch();
		if (!$row) {
			return false;
		}
		
		$this->setDBProperties($row);
		
		return true;
	}

	/**
	 * Saves the object to the database.
	 * 
	 * @return boolean Whether the object successfully saved
	 */
	public function save()
	{
		$this->presave();
		
		$tableName	= $this->inflect('table');
		$pk			= $this->inflect('primaryKey');
		$id			= $this->inflect('property', array('fieldName' => $pk));
		$created	= $this->inflect('dateCreated');
		$modified	= $this->inflect('dateModified');
		
		// set the automatic timestamps
		if (!isset($this->$id) && $this->hasProperty($created))
			$this->{$created} = $this->_formatDate(time() - date('Z') + self::$timeOffset);
		
		if ($this->hasProperty($modified))
			$this->{$modified} = $this->_formatDate(time() - date('Z') + self::$timeOffset);
		
		$props = $this->getDBProperties();
		
		if ($this->$id) {
			$where = $this->_db->quoteInto("$pk = ?", $this->$id);
			$success = $this->_db->update($tableName, $props, $where);
		} else {
			$success = $this->_db->insert($tableName, $props);
		}
		
		if (!$success) {
			return false;
		}
		
		if (!$this->$id) {
			$this->$id = $this->_db->lastInsertId();
		}
		
		$this->postsave();
		
		return true;
	}

	/**
	 * Check if this object has a property which may or may not be set. This
	 * cannot necessarily be known through isset()
	 *
	 * @param string $propName The name of the property being looked for
	 * @return boolean Wether this object has the property defined or set
	 */
	public function hasProperty($propName)
	{
		return array_key_exists($propName, get_public_object_vars($this));
	}

	/**
	 * Returns the set and defined-but-not-set properties of an object
	 *
	 * @return array An associative array of properties
	 */
	public function getProperties()
	{
		return get_public_object_vars($this);
	}

	/**
	 * Sets the properties on an object filtering out only the ones which are
	 * already set or defined on the object. Perfect for filling an object from
	 * $_GET or $_POST.
	 *
	 * @param array An assoc. array of properties to pouplate the object with.
	 * @return Zend_Db_ActiveRecord_Abstract Returns $this
	 */
	public function setProperties($properties)
	{
		$props = get_public_object_vars($this);
		foreach ($props as $prop => $value) {
			if (key_exists($prop, $properties)) {
				$this->$prop = $properties[$prop];
			}
		}
		return $this;
	}

	/**
	 * Returns the properties on an object which are stored in the database. Used
	 * to generate the SQL statements for saving the object
	 *
	 * @param string $join The join table which this object may be linked from
	 * @return array An associative array of properties
	 */
	protected function getDBProperties($join = "")
	{
		$pk	= $this->inflect('primaryKey');
		
		if ($join) {
			$columns = $this->getMetadata($join);
		} else {
			$columns = $this->getMetadata();
		}
		
		$values = array();
		foreach ($columns as $fieldName => $info) {
			if ($fieldName == $pk) {
				continue;
			}
			
			$value = $this->{$info->name};
			if ($info->type == "numeric" && $value != null) {
				$value = $value + 0;
			} elseif ($info->type == "date" && $value) {
				$value = $this->_formatDate(strtotime($value) - self::$timeOffset);
			} elseif (is_null($value) && !$info->nullable && !$join) {
				continue; // assuming this is not meant to be changed
			}
			$values[$fieldName] = $value;
		}
		return $values;
	}

	
	/**
	 * Sets the properties on an object which are stored in the database. Used
	 * to populate the object from a load call
	 *
	 * @param array An assoc. array of properties to pouplate the object with.
	 * If not provided will create properties for each DB field with a null value
	 */
	protected function setDBProperties($row = null)
	{
		// allow extra properties to be defined in subclasses which may not be
		// part of the table columns but could still be populated through queries 
		$definedProperties = $this->getProperties();
		$columns = $this->getMetadata();
		
		if ($row) {
			$this->preload();
			
			foreach ($row as $key => $value) {
				$prop = $this->inflect('property', array('fieldName' => $key));
				if (key_exists($key, $columns)) {
					$info = $columns[$key];
					
					if ($info->type == "numeric" && $value != null) {
						$value = $value + 0;
					} elseif ($info->type == "date") {
						if (!$value || $value == '0000-00-00 00:00:00') {
							$value = '';
						} elseif (self::$timeOffset != 0) {
							$value = $this->_formatDate(strtotime($value) + self::$timeOffset);
						}
					} elseif ($info->type == "boolean" && is_numeric($value)) {
						$value = $value ? true : false;
					}
				}
				
				$this->$prop = $value;
				unset($definedProperties[$prop]);
			}
			
			// only allow properties to be set which came from the database, but don't
			// touch the ones custom defined for this class.
			foreach ($definedProperties as $key => $value) {
				$fieldName = $this->inflect('field', array('propertyName' => $key));
				if (key_exists($fieldName, $columns)) {
					unset($this->$key);
				}
			}
			
			$this->postload();
		} else {
			// populate defaults
			foreach ($columns as $fieldName => $info) {
				if (isset($row[$fieldName])) {
					$value = $row[$fieldName];
				} else {
					$value = $info->default;
				}
				
				if ($info->type == "numeric" && $value != null) {
					$value = $value + 0;
				} elseif ($info->type == "date" && $value == '0000-00-00 00:00:00') {
					$value = '';
				} elseif ($info->type == "boolean" && is_numeric($value)) {
					$value = $value ? true : false;
				}
				
				$this->{$info->name} = $value;
			}
		}
		
	}


	// MAGIC METHODS FOR MODEL ******************************************
	


	/**
	 * Catches all methods called and forwards on certain types to their
	 * defined method. e.g. $this->doSomething(10) will call
	 * $this->_do("Something", 10) if the _do method is defined.
	 *
	 * @param string $func The name of the method
	 * @param array $args The arguments passed to it
	 * @return mixed The return value of the resolved method
	 */
	protected function __call($func, $args)
	{
		$matches = null;
		preg_match('/(^[^A-Z]*)([A-Z].*)/', $func, $matches);
		$catchFunc = '_' . $matches[1];
		// push the property name to the front of the args array
		array_unshift($args, lcfirst($matches[2]));
		
		if (method_exists($this, $catchFunc)) {
			return call_user_func_array(array($this, $catchFunc), $args);
		} else {
			throw new Exception("Method " . get_class($this) . "::$func() does not exist.");
		}
	}

	/**
	 * Catches all set actions to undefined properties and assumes they are related
	 * objects. Tries to save related object or array of objects.
	 *
	 * @param string $property Name of unset property we are setting.
	 * @return mixed The object or array of objects we are trying to set.
	 */
	protected function __set($property, $value)
	{
		$this->_set($property, $value);
	}

	/**
	 * Catches all get requests to undefined properties and assumes they are related
	 * objects. Tries to find and load related object or array of objects.
	 *
	 * @param string $property Name of unset property we are getting.
	 * @return mixed The object or array of objects we are trying to get (or null if not found).
	 */
	protected function __get($property)
	{
		if (self::$loadOnGet) {
			return $this->_get($property);
		}
	}

	
	/**
	 * Does automatic date conversion (to int) for database date properties and
	 * save relational objects on the fly. Calls $this->loadProperty which
	 * may be provided by subclass if custom loading is required
	 *
	 * @param string $prop
	 * @param mixed $value
	 */
	protected function _set($prop, $value)
	{
		$fieldName = $this->inflect('field', array('propertyName' => $prop));
		$columns = $this->getMetadata();
		if (isset($columns[$fieldName])) {
			$info = $columns[$fieldName];
			if ($info->type == "date") {
				if (is_numeric($value))
					$value = $this->_formatDate($value);
			} elseif ($info->type == "numeric" && $value != null) {
				$value += 0;
			}
		}
		
		$this->$prop = $value;
		/*
		// $this->$prop could be set if _set is reached through $this->setProperty($value);
		if ((is_object($value) || is_array($value)) && !isset($this->$prop)) {
			$this->{"save" . ucfirst($prop)}();
		}*/
	}

	/**
	 * Loads relational objects on the fly. Calls $this->loadProperty which
	 * may be provided by subclass if custom loading is required
	 * 
	 * @param string $property Property to get
	 * @return mixed The value returned from this object
	 */
	protected function _get($property)
	{
		if (!isset($this->$property)) {
			$singular = new Zend_Filter_Word_PluralToSingular();
			if (class_exists(ucfirst($property), false) || class_exists($singular->filter(ucfirst($property)), false)) {
				$this->{"load" . ucfirst($property)}();
			}
		}
		return isset($this->$property) ? $this->$property : null;
	}

	/**
	 * Adds an object to an array and saves relational objects on the fly
	 *
	 * @param string $property
	 * @param mixed $value
	 */
	protected function _add($property, $value)
	{
		$plural = new Zend_Filter_Word_SingularToPlural();
		$property = $plural->filter($property);
		if (!isset($this->$property)) {
			$this->{"load" . ucfirst($property)}();
		}
		
		array_push($this->$property, $value);
		$this->{'save' . ucfirst($property)}();
	}

	/**
	 * Removes an object from an array and removes the relationship of relational objects
	 *
	 * @param string $property
	 * @param obj/int $objOrNum
	 * @return mixed Returns the object removed from the array
	 */
	protected function _remove($property, $objOrNum)
	{
		$plural = new Zend_Filter_Word_SingularToPlural();
		$property = $plural->filter($property);
		if (!isset($this->$property)) {
			$this->{"load" . ucfirst($property)}();
		}
		// TODO save object after removing it (save this or that object, depending on where Fkey is)
		if (is_numeric($objOrNum)) {
			$removed = array_splice($this->$property, $objOrNum, 1);
			return $removed[0];
		}
		
		foreach ($this->$property as $index => $tempObj) {
			if ($tempObj === $objOrNum) {
				$removed = array_splice($this->$property, $index, 1);
				return $removed[0];
			}
		}
		return false;
	}

	/**
	 * Gives the size of an array property, loading relational objects on the fly
	 *
	 * @param string $property
	 * @return int
	 */
	protected function _sizeof($property)
	{
		if ($this->relations[$property] && !isset($this->$property))
			$this->{'load' . ucfirst($property)}();
		if (!is_array($this->$property))
			$this->$property = array();
		return sizeof($this->$property);
	}

	
	// Database automatic methods
	


	protected function _load($property, Zend_Db_Select $sql = null)
	{
		if (isset($this->$property)) {
			return $this->$property;
		}
		
		$this->$property = $this->__runQuery('fetch', 'fetchAll', $property, $sql);
		
		return $this->$property;
	}

	protected function _count($property, Zend_Db_Select $sql = null)
	{
		return $this->__runQuery('count', 'count', $property, $sql);
	}

	protected function _delete($property, Zend_Db_Select $sql = null)
	{
		if (!isset($this->$property)) {
			return false;
		}
		
		return $this->__runQuery('deleteAll', 'deleteAll', $property, $sql);
	}
	
	protected function __runQuery($singularAction, $pluralAction, $property, $sql)
	{
		$id = $this->inflect('id');
		
		if (!isset($this->$id)) {
			throw new Zend_Db_Exception("A primary key must be set in order to perform this operation");
		}
		
		// get all needed info between this class and that class //
		$thisClass = $this->_className;
		$thatClass = ucfirst($property);
		$singularize = new Zend_Filter_Word_PluralToSingular();
		$plural = false;
		if (!class_exists($thatClass, false)) {
			$thatClass = $singularize->filter($thatClass);
			if (!class_exists($thatClass, false)) {
				throw new Exception("Class not loaded to populate the $thisClass::$property property.");
			}
			$plural = true; // This property was plural, so it is an array relationship
		}
		
		$gateway = Zend_Db_ActiveRecord_Gateway::getInstance($thatClass);
		$thisTable = $this->inflect('table');
		$thatTable = $gateway->inflect('table');
		$thisPK = $this->inflect('primaryKey');
		$thatPK = $gateway->inflect('primaryKey');
		$thisFK = $this->inflect('foreignKey');
		$thatFK = $gateway->inflect('foreignKey');
		
		$thisFields = $this->getMetadata($thisTable);
		$thatFields = $this->getMetadata($thatTable);
		if (!$thisFields || !$thatFields) {
			return null;
		}
		
		if (!$sql) {
			$sql = $this->select();
		}
		
		// determine the relationship //
		if (!$plural) {
			if (isset($thisFields[$thatFK])) { // Belongs-To
				return $gateway->$singularAction($sql->where("$thatPK = ?", $this->{$this->inflect('property', array('fieldName' => $thatFK))} ));
			} elseif (isset($thatFields[$thisFK])) { // Has-One
				return $gateway->$singularAction($sql->where("$thisFK = ?", $this->$id));
			}
		} else {
			if (isset($thatFields[$thisFK])) { // Has-Many
				return $gateway->$pluralAction($sql->where("$thisFK = ?", $this->$id));
			} else { // Many-To-Many/Has-And-Belongs-To
				$joinTable = $this->inflect('joinTable', array('className1' => $thisClass, 'className2' => $thatClass));
				$joinFields = $this->getMetadata($joinTable);
				if (!$joinFields) {
					$joinTable = $this->inflect('joinTable', array('className1' => $thatClass, 'className2' => $thisClass));
					$joinFields = $this->getMetadata($joinTable);
				}
			
				if ($joinFields) {
					$sql->from($thatTable);
					$sql->join($joinTable, "`$thatTable`.$thatPK = `$joinTable`.$thatFK");
					$sql->where("`$joinTable`.$thisFK = ?", $this->$id);
					return $gateway->$pluralAction($sql);
				}
			}
		}
		
		throw new Zend_Db_Exception("ActiveRecord could not find the realtionship between '$thisClass' and '$thatClass'");
	}

	protected function _save($property, $options = array())
	{
		if (!isset($this->$property)) {
			return false;
		}
		
		// get all needed info between this class and that class //
		$thisClass = $this->_className;
		$thatClass = ucfirst($property);
		$singularize = new Zend_Filter_Word_PluralToSingular();
		$plural = false;
		if (!class_exists($thatClass, false)) {
			$thatClass = $singularize->filter($thatClass);
			if (!class_exists($thatClass, false)) {
				throw new Exception("Class not found to load the $thisClass::$property property.");
			}
			$plural = true; // This property was plural, so it is an array relationship
		}
		
		$gateway = Zend_Db_ActiveRecord_Gateway::getInstance($thatClass);
		$thisTable = $this->inflect('table');
		$thatTable = $gateway->inflect('table');
		$thisPK = $this->inflect('primaryKey');
		$thatPK = $gateway->inflect('primaryKey');
		$thisFK = $this->inflect('foreignKey');
		$thatFK = $gateway->inflect('foreignKey');
		$thisID = $this->inflect('property', array('fieldName' => $thisPK));
		$thatID = $gateway->inflect('property', array('fieldName' => $thatPK));
		
		$thisFields = $this->getMetadata($thisTable);
		$thatFields = $this->getMetadata($thatTable);
		if (!$thisFields || !$thatFields) {
			return null;
		}

		// determine the relationship //
		if (!$plural) {
			if (!is_object($this->$property)) {
				throw new Exception("$thisClass::$property is not an object, cannot save it.");
			}
			if (isset($thisFields[$thatFK])) { // Belongs-To
				$this->$property->save();
				$this->$thatFK = $this->$property->$thatID;
				return $this->save();
			} elseif (isset($thatFields[$thisFK])) { // Has-One
				if (!isset($this->$thisID)) {
					$this->save();
				}
				$this->$property->$thisFK = $this->$thisID;
				return $this->$property->save();
			}
		} else {
			if (!is_array($this->$property)) {
				throw new Exception("$thisClass::$property is not an array, cannot save it.");
			}
			if (isset($thatFields[$thisFK])) { // Has-Many
				if (!isset($this->$thisID)) {
					$this->save();
				}
				$ids = array();
				foreach ($this->$property as $obj) {
					if ($obj->$thisFK != $this->$thisID || $options['forceSave']) {
						$obj->$thisFK = $this->$thisID;
						$obj->save();
					}
					$ids[] = $obj->$thatID;
				}
				
				// delete any that are not part of the array
				if ($options['deleteOthers']) {
					$select = $gateway->select()->where("$thisFK = ?", $this->$thisID);
					if (count($ids)) {
						$select->where("$thatPK NOT IN (?)", $ids);
					}
					
					$gateway->delete($select);
				}
				
				return true;
			} else { // Many-To-Many/Has-And-Belongs-To
				$joinTable = $this->inflect('joinTable', array('className1' => $thisClass, 'className2' => $thatClass));
				$joinFields = $this->getMetadata($joinTable);
				if (!$joinFields) {
					$joinTable = $this->inflect('joinTable', array('className1' => $thatClass, 'className2' => $thisClass));
					$joinFields = $this->getMetadata($joinTable);
				}
				
				if ($joinFields) {
					if (!isset($this->$thisID)) {
						$this->save();
					}
					$props = $this->getDBProperties($joinTable);
					
					if ($options['deleteOthers']) {
						$this->_db->query("DELETE FROM `$joinTable` WHERE $thisFK = ?", array($this->$thisID));
					} else {
						$delStmt = $this->_db->prepare("DELETE FROM `$joinTable` WHERE $thisFK = ? AND $thatFK = ?");
					}
					$insStmt = $this->_db->prepare("INSERT INTO `$joinTable` (" . implode(', ', array_keys($props)) . ") VALUES (?" . str_repeat(", ?", count($props) - 1) . ")");
					foreach ($this->$property as $obj) {
						if ($options['forceSave'] || !$obj->$thatID) {
							$obj->save();
						}
						$props[$thisFK] = $this->$thisID;
						$props[$thatFK] = $obj->$thatID;
						
						if (!$options['deleteOthers']) {
							$delStmt->execute(array($this->$thisID, $obj->$thatID));
						}
						$insStmt->execute(array_values($props));
					}
					return true;
				}
			}
		}
		
		throw new Zend_Db_Exception("ActiveRecord could not find the realtionship between '$thisClass' and '$thatClass'");
	}

	
	/*****************************************************/
	/* PROTECTED STATIC UTILITY METHODS                  */
	/*****************************************************/
	
	/**
	 * Generates the subclass database info and stores it statically
	 *
	 * @param string $className The name of the class
	 * @param boolean $isJoin Determines whether this is stored as join table
	 * 	info or as a normal class defenition
	 */
	public function getMetadata($tableName = null)
	{
		$cache = ActiveRecordCache::getCache();
		
		if ($tableName == null) {
			$tableName = $this->inflect('table');
		}
		
		if (isset($cache->tables[$tableName])) {
			return $cache->tables[$tableName];
		}
		
		$columns = $this->_db->describeTable($tableName);
		
		$properties = array();
		foreach ($columns as $info) {
			$prop = new stdClass();
			
			foreach ($info as $key => $value) {
				$prop->{strtolower($key)} = $value;
			}
			
			$prop->name = $this->inflect('property', array('fieldName' => $prop->column_name));
			
			$prop->type = 'string';
			foreach (self::$typeconv as $regex => $type) {
				if (preg_match("/$regex/i", $prop->data_type)) {
					$prop->type = $type;
					break;
				}
			}
			
			$properties[$prop->column_name] = $prop;
		}
		
//		--example object--
//		'schema_name' => NULL,
//		'table_name' => 'accounts',
//		'column_name' => 'id',
//		'column_position' => 1,
//		'data_type' => 'int',
//		'default' => NULL,
//		'nullable' => false,
//		'length' => NULL,
//		'scale' => NULL,
//		'precision' => NULL,
//		'unsigned' => true,
//		'primary' => true,
//		'primary_position' => 1,
//		'identity' => true,
//		'name' => 'id',
//		'type' => 'numeric',
		$cache->tables[$tableName] = $properties;
		return $properties;
	}

	public function inflect($inflection, array $values = array())
	{
		if (!isset($this->_inflectors[$inflection])) {
			throw new Zend_Db_Exception('Inflection "' . $inflection . '" is invalid');
		}
		
		$values = array_merge(array('className' => $this->_className), $values);
		return $this->_inflectors[$inflection]->filter($values);
	}
	
	public function __toString()
	{
		$str = "[$this->_className] {\n";
		foreach ($this->getProperties() as $key => $value) {
			$str .= "\t$key => $value\n";
		}
		$str .= "}";
		return $str;
	}
	
	public function __sleep()
	{
		return array_keys(get_public_object_vars($this));
	}
	
	public function __wakeup()
	{
		$this->_setup();
		$this->init();
	}
}


class ActiveRecordCache
{
	private static $cache;
	private static $filename = "application/cache/ar_cache.cache";

	public static function getCache()
	{
		if (!self::$cache) {
			
			if (!Zend_Registry::get('config')->debug)
				self::$cache = file_exists(self::$filename) ? unserialize(file_get_contents(self::$filename)) : new stdClass();
			else
				self::$cache = new stdClass();
			
			if (!isset(self::$cache->tables) || !is_array(self::$cache->tables)) {
				self::$cache->tables = array();
				self::$cache->callers = array();
			}
		}
		return self::$cache;
	}

	public static function saveCache()
	{
		if (!Zend_Registry::get('config')->debug && self::$cache)
			file_put_contents(self::$filename, serialize(self::$cache));
	}
}

function get_public_object_vars($object)
{
	return get_object_vars($object);
}

function lcfirst($string)
{
	return strtolower($string{0}) . substr($string, 1);
}